﻿namespace Contoso.ActiveDirectory {

    #region Usings
    using System;
    using System.Collections.Concurrent;
    using System.Collections.Generic;
    using System.Diagnostics;
    using System.DirectoryServices;
    using System.DirectoryServices.AccountManagement;
    using System.Linq;
    using System.Security.Principal;
    using System.Text;
    using System.Text.RegularExpressions;
    using System.Threading;
    using System.Threading.Tasks;
    #endregion

    public class GroupMethods {

        public GroupProperties CreateGroup(string ouDN, string samAccountName, string displayName, GroupTypeFlags groupType) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} OU DN: {3} samAccountName: {4} DisplayName: {5} GroupType: {6}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                ouDN ?? "NULL", samAccountName ?? "NULL", displayName ?? "NULL", groupType));

            #region Validation
            if (string.IsNullOrWhiteSpace(ouDN)) {
                throw new ArgumentNullException("ouDN");
            }
            if (string.IsNullOrWhiteSpace(samAccountName)) {
                throw new ArgumentNullException("samAccountName");
            }
            if (!IsGroupSamAccountNameValid(samAccountName)) {
                throw new InvalidOperationException(string.Format("samAccountName invalid: {0}", samAccountName));
            }
            #endregion

            var methodParameters = new MethodParameters() {
                OUDN = ouDN.Trim(),
                SamAccountName = samAccountName.Trim(),
                DisplayName = displayName != null ? displayName.Trim() : string.Empty,
                GroupType = groupType
            };

            return ConnectionManager.DirectoryServerHandler<MethodParameters, GroupProperties>(
                    ref methodParameters,
                    (x) => {
                        methodParameters.ServerNameOrAdjustedBindPath = ADDataAccess.GetAdjustedBindPath(forupdate: true, bindPath: methodParameters.OUDN);

                        #region Validation
                        if (!ADDataAccess.ObjectExists(methodParameters.ServerNameOrAdjustedBindPath)) {
                            throw new ApplicationException(string.Format("Path does not exist: {0}", methodParameters.ServerNameOrAdjustedBindPath));
                        }
                        #endregion

                        using (var ouDirectoryEntry = new DirectoryEntry(methodParameters.ServerNameOrAdjustedBindPath, null, null, methodParameters.AuthenticationTypes))
                        using (var groupDirectoryEntry = ouDirectoryEntry.Children.Add(string.Format("CN={0}", methodParameters.SamAccountName.Trim()), "group")) {
                            
                            groupDirectoryEntry.Properties["sAMAccountName"].Add(methodParameters.SamAccountName);
                            groupDirectoryEntry.Properties["groupType"].Value = (int)methodParameters.GroupType;
                            if (!string.IsNullOrWhiteSpace(methodParameters.DisplayName)) {
                                groupDirectoryEntry.Properties["displayName"].Value = methodParameters.DisplayName;
                            }
                            groupDirectoryEntry.CommitChanges();
                            groupDirectoryEntry.RefreshCache();

                            var adGroup = new GroupProperties(groupDirectoryEntry);

                            return adGroup;
                        }
                    });
        }

        /// <summary>
        /// Mail-enabled groups require specifying the mailNickname and proxyAddresses attributes
        /// </summary>
        /// <param name="allowAllSenders">True to allow any user to send email this mail-enabled group.</param>
        /// <param name="allowUnauthenticatedSenders">True to allow unauthenticated senders to send email to this mail-enabled group (Internet).</param>
        /// <param name="displayName">The group display name</param>
        /// <param name="emailAddress">The group SMTP address</param>
        /// <param name="groupType">The group type.</param>
        /// <param name="ouDN">The organizational unit or distinguished name base path where the group will be created.</param>
        /// <param name="samAccountName">The group samAccountName</param>
        public GroupProperties CreateMailEnabledGroup(
            string ouDN, string samAccountName, string displayName, GroupTypeFlags groupType, string emailAddress,
            bool allowAllSenders = true, bool allowUnauthenticatedSenders = true) {

            #region Validation
            if (string.IsNullOrWhiteSpace(ouDN)) {
                throw new ArgumentNullException("ouDN");
            }
            if (string.IsNullOrWhiteSpace(samAccountName)) {
                throw new ArgumentNullException("samAccountName");
            }
            if (string.IsNullOrWhiteSpace(displayName)) {
                throw new ArgumentNullException("displayName");
            }
            if (!IsGroupSamAccountNameValid(samAccountName)) {
                throw new InvalidOperationException(string.Format("samAccountName invalid: {0}", samAccountName));
            }
            #endregion
            
            var adGroupProperties = CreateGroup(ouDN, samAccountName, displayName, groupType);
            ADDataAccess.UpdateAttribute(adGroupProperties.DN, "mail", emailAddress);

            // NOTE: SMTP addresses should be prefixed with SMTP: if the primary address (same as the mail attribute), or smtp: if the secondary address

            var proxyAddress = string.Format("SMTP:{0}", emailAddress);
            ADDataAccess.UpdateAttribute(adGroupProperties.DN, "proxyAddresses", proxyAddress);

            if (allowAllSenders) {
                // clear authOrig attribute. Probably unnecessary on new groups.
                ADDataAccess.ClearAttribute(adGroupProperties.DN, "authOrig");
            }
            if (allowUnauthenticatedSenders) {
                // clear msExchRequireAuthToSendTo to remove authentication requirement (allow email from Internet).
                ADDataAccess.ClearAttribute(adGroupProperties.DN, "msExchRequireAuthToSendTo");
            }

            return adGroupProperties.Refresh<GroupProperties>();
        }

        /// <summary>
        /// Gets the memberOf contructed/backlink attribute for a specified object
        /// </summary>
        /// <param name="objectDN">DN of the object for which we are getting the groups</param>
        /// <returns>List of group DNs</returns>
        /// <remarks>Direct group memberships.  Does not include nested/indirect.</remarks>
        public IReadOnlyList<string> GetMemberOf(string objectDN) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} DN: {3}", DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), objectDN));

            #region Validation
            if (string.IsNullOrWhiteSpace(objectDN)) {
                throw new ArgumentNullException("objectDN");
            }
            #endregion

            var methodParameters = new MethodParameters() {
                ObjectDN = ADDataAccess.GetDNFromLdapDN(objectDN.Trim()),
                DomainDN = ADDataAccess.GetDomainDNFromDN(ADDataAccess.GetDNFromLdapDN(objectDN.Trim()))
            };

            return ConnectionManager.DirectoryServerHandler<MethodParameters, IReadOnlyList<string>>(
                    ref methodParameters,
                    (x) => {
                        methodParameters.ServerNameOrAdjustedBindPath = ADDataAccess.GetAdjustedBindPath(methodParameters.ObjectDN, PartitionScope.LocalPartition);

                        #region Validation
                        if (!ADDataAccess.ObjectExists(methodParameters.ServerNameOrAdjustedBindPath)) {
                            throw new ApplicationException(string.Format("Path does not exist: {0}", methodParameters.ServerNameOrAdjustedBindPath));
                        }
                        #endregion

                        var groupDNs = new List<string>();
                        string filter = string.Format("distinguishedName={0}", methodParameters.ObjectDN);
                        string[] propertiesToGet = new string[] { "memberOf" };
                        List<SearchResult> results = ADDataAccess.GetSearchResults(methodParameters.DomainDN, propertiesToGet, filter, PartitionScope.LocalPartition);

                        foreach (SearchResult result in results) {
                            methodParameters.ServerNameOrAdjustedBindPath = result.Path;

                            for (int index = 0; index < result.Properties["memberof"].Count; index++) {
                                string groupDN = result.Properties["memberof"][index].ToString();
                                if (!ADDataAccess.ExcludedDomains.Any(excludedDomain => (Regex.IsMatch(groupDN, string.Format("DC={0}", excludedDomain), RegexOptions.IgnoreCase | RegexOptions.Compiled)))) {
                                    groupDNs.Add(groupDN);
                                }
                            }
                        }

                        return groupDNs as IReadOnlyList<string>;
                    });
        }

        public IReadOnlyList<GroupProperties> GetAllGroupMemberships(string objectDN, List<GroupProperties> groups = null, int recursionDepth = 0) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}] {2} dn: {3}", DateTime.Now.TimeOfDay,
                Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), objectDN ?? "NULL"));

            #region Validation
            if (string.IsNullOrWhiteSpace(objectDN)) {
                throw new ArgumentNullException(objectDN);
            } 
            #endregion

            recursionDepth++;

            if (groups == null) {
                groups = new List<GroupProperties>();
            }

            if (recursionDepth < ADDataAccess.MaxGroupSearchRecursionDepth) {
                foreach (var group in GetDirectGroupMemberships(objectDN)) {
                    if (!groups.Any(x => x.DN == group.DN)) {
                        groups.Add(group);
                        GetAllGroupMemberships(group.DN, groups, recursionDepth);
                    }
                }
            }
            else {
                Debug.WriteLine(string.Format("{0}  [ThreadID: {1}] {2} dn: {3}; not searching for memberships due to search due to max recursion depth has been reached: {4}",
                    DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                    objectDN ?? "NULL", ADDataAccess.MaxGroupSearchRecursionDepth));
            }

            return groups as IReadOnlyList<GroupProperties>;
        }

        /// <summary>
        /// Recursively gets the DNs for all groups that are a member of the specified group dn
        /// </summary>
        /// <param name="groupDN">dn of the top-level group</param>
        /// <remarks>Not fully tested.</remarks>
        public async Task<ConcurrentDictionary<string, int>> GetChildGroupDNsAsync(string groupDN) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                groupDN ?? "NULL"));

            var stopwatch = Stopwatch.StartNew();
            ConcurrentDictionary<string, int> childGroupDNs = new ConcurrentDictionary<string, int>(StringComparer.OrdinalIgnoreCase);
            ConcurrentBag<Task> tasks = new ConcurrentBag<Task>();
            TimeSpan taskSchedulerIdleTimeout = TimeSpan.FromSeconds(25);
            TimeSpan getChildGroupsTimeout = TimeSpan.FromMinutes(10);

            using (var cts = new CancellationTokenSource(getChildGroupsTimeout)) {
                var aDChildGroupDNSearchCriteria = new ChildGroupDNSearchCriteria(
                        groupDN: groupDN,
                        domainDN: ADDataAccess.GetDomainDNFromDN(groupDN),
                        childGroupDNs: childGroupDNs, 
                        groupSearchRecursionDepth: 0,
                        tasks: tasks,
                        cts: cts
                    );

                return await Task.Run<ConcurrentDictionary<string, int>>(async () => {
                    var mainTask = await Task.Factory.StartNew(async (x) => {
                        await GetChildGroupDNsAsync(aDChildGroupDNSearchCriteria);
                    }, groupDN, cts.Token);
                    tasks.Add(mainTask);

                    int previousTaskCount = tasks.Count;
                    while (true) {
                        Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} Waiting for {3} of {4} tasks to complete...",
                            DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                            tasks.Where(x => x.Status != TaskStatus.RanToCompletion).Count().ToString(), tasks.Count));

                        Task.WaitAll(tasks.ToArray());

                        if (tasks.Count != previousTaskCount) {
                            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} Previous Task Count: {3}. Current Task Count: {4} Waiting 10 seconds...",
                                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                                previousTaskCount, tasks.Count));

                            previousTaskCount = tasks.Count;
                            await Task.Delay(taskSchedulerIdleTimeout);
                            continue;
                        }

                        break;
                    } // while (true)

                    Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} finished getting child group DN's for DN: {3}. Child groups found: {4} Time required: {5} tasks count: {6}",
                        DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                        groupDN ?? "NULL", childGroupDNs.Count, stopwatch.Elapsed, tasks.Count));

                    return childGroupDNs;
                });
            }
        }
        public async Task GetChildGroupDNsAsync(ChildGroupDNSearchCriteria childGroupDNSearchCriteria) {

            #region Validation
            if (childGroupDNSearchCriteria == null) {
                throw new ArgumentNullException("childGroupDNSearchCriteria");
            }
            if (string.IsNullOrWhiteSpace(childGroupDNSearchCriteria.GroupDN)) {
                throw new ArgumentNullException("childGroupDNSearchCriteria.GroupDN");
            }
            #endregion

            #region Logging information
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3} domain DN: {4} recursion depth: {5} process thread count: {6}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                childGroupDNSearchCriteria.GroupDN ?? "NULL", childGroupDNSearchCriteria.DomainDN ?? "NULL (All domains)",
                childGroupDNSearchCriteria.CurrentRecursionDepth.ToString(), Process.GetCurrentProcess().Threads.Count));
            #endregion

            #region Leave the domainDN null to search all domains
            if (string.IsNullOrWhiteSpace(childGroupDNSearchCriteria.DomainDN)) {
                foreach (string domainDN in ADDataAccess.ADDomainInfo.Select(x => x.DistinguishedName)) {
                    ChildGroupDNSearchCriteria newChildGroupDNSearchCriteria = new ChildGroupDNSearchCriteria(
                        childGroupDNSearchCriteria.GroupDN,
                        domainDN,
                        childGroupDNSearchCriteria.ChildGroupDNs,
                        childGroupDNSearchCriteria.CurrentRecursionDepth,
                        childGroupDNSearchCriteria.Tasks,
                        childGroupDNSearchCriteria.CTS
                    );

                    var childTask = await Task.Factory.StartNew(async (x) => {
                        await GetChildGroupDNsAsync(newChildGroupDNSearchCriteria);
                    }, newChildGroupDNSearchCriteria.GroupDN, newChildGroupDNSearchCriteria.CTS.Token, TaskCreationOptions.AttachedToParent, TaskScheduler.Current);
                    childGroupDNSearchCriteria.Tasks.Add(childTask);
                }
                return;
            }
            #endregion

            #region Check for MaxRecursionDepth
            if (childGroupDNSearchCriteria.CurrentRecursionDepth > ADDataAccess.MaxGroupSearchRecursionDepth) {
                string message = string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3} Canceling search due to GroupSearchRecursionDepth: {4} exceeds MaxGroupSearchRecursionDepth: {5}",
                                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                                childGroupDNSearchCriteria.GroupDN, childGroupDNSearchCriteria.CurrentRecursionDepth, ADDataAccess.MaxGroupSearchRecursionDepth);

                throw new ApplicationException(message);
            }
            #endregion

            string[] propertiesToGet = new string[] { "distinguishedName", "groupType" };

            await Task.Run(() => {
                string filter = string.Format("(&(objectCategory=Group)(distinguishedName={0}))",
                    ADDataAccess.ADSearchFilterEncode(childGroupDNSearchCriteria.GroupDN));

                List<GroupProperties> adGroups = ADDataAccess.GetADObjectProperties<GroupProperties>(
                    childGroupDNSearchCriteria.DomainDN, propertiesToGet, filter, PartitionScope.LocalPartition);

                if ((adGroups == null) || (adGroups.Count != 1)) {
                    throw new ApplicationException(string.Format("Unable to locate one group for DN: {0}.  Group count returned: {1}",
                        childGroupDNSearchCriteria.GroupDN, adGroups != null ? adGroups.Count.ToString() : "NULL"));
                }

                int memberType = 0;

                GroupProperties adGroup = adGroups.First();

                if (adGroup.GroupType.HasFlag(GroupTypeFlags.Local) || adGroup.GroupType.HasFlag(GroupTypeFlags.BuiltIn)) {

                    #region Case Local or Builtin group
                    if (adGroup.GetGroupMemberDNs()) {
                        foreach (string memberDN in adGroup.Member.Keys) {
                            // check if this is a group
                            string childFilter = string.Format("(&(objectCategory=Group)(distinguishedName={0}))", memberDN);
                            var memberDomainDN = ADDataAccess.GetDomainDNFromDN(memberDN);

                            List<GroupProperties> childGroups = ADDataAccess.GetADObjectProperties<GroupProperties>(
                                memberDomainDN, propertiesToGet, childFilter, PartitionScope.LocalPartition);

                            if ((childGroups == null) || (childGroups.Count != 1)) continue;
                            GroupProperties childGroup = childGroups.Single();

                            if (!childGroupDNSearchCriteria.ChildGroupDNs.ContainsKey(memberDN)) {
                                childGroupDNSearchCriteria.ChildGroupDNs.AddOrUpdate(memberDN, 0, (TKey, TOldValue) => memberType);
                            }
                            else {
                                Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} ChildGroupDNs already contains DN: {3}",
                                    DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), memberDN));
                                continue;
                            }

                            // requeue this dn to check if it is a group and what the group members are
                            ChildGroupDNSearchCriteria newChildGroupDNSearchCriteria = new ChildGroupDNSearchCriteria(
                                memberDN,
                                ADDataAccess.GetDomainDNFromDN(memberDN),
                                childGroupDNSearchCriteria.ChildGroupDNs,
                                childGroupDNSearchCriteria.CurrentRecursionDepth,
                                childGroupDNSearchCriteria.Tasks,
                                childGroupDNSearchCriteria.CTS
                            );

                            childGroupDNSearchCriteria.Tasks.Add(Task.Factory.StartNew(async (x) => {
                                await GetChildGroupDNsAsync(newChildGroupDNSearchCriteria);
                            }, newChildGroupDNSearchCriteria.GroupDN, newChildGroupDNSearchCriteria.CTS.Token, TaskCreationOptions.AttachedToParent, TaskScheduler.Current));

                        } // foreach (string memberDN in adGroup.Member.Keys)
                    } // if (adGroup.GetGroupMemberDNs()) 
                    #endregion
                } 
                else {

                    #region Normal case test
                    // perform the normal case test
                    string memberOfFilter = string.Format("(&(objectCategory=group)(memberOf={0}))", adGroup.DN);

                    List<GroupProperties> memberGroups = ADDataAccess.GetADObjectProperties<GroupProperties>(
                        ADDataAccess.ForestRootDN, propertiesToGet, memberOfFilter, PartitionScope.GlobalCatalog);

                    if ((memberGroups == null) || (memberGroups.Count == 0)) return;

                    // Find the DNs for groups that are a member of this group
                    foreach (var childGroup in memberGroups) {
                        if (!childGroupDNSearchCriteria.ChildGroupDNs.ContainsKey(childGroup.DN)) {
                            childGroupDNSearchCriteria.ChildGroupDNs.AddOrUpdate(childGroup.DN, 0, (TKey, TOldValue) => memberType);
                        }
                        else {
                            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} ChildGroupDNs already contains DN: {3}",
                                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), childGroup.DN));
                            continue;
                        }

                        // recursively build a list of all the groups that are a member of this group
                        ChildGroupDNSearchCriteria newChildGroupDNSearchCriteria = new ChildGroupDNSearchCriteria(
                            childGroup.DN,
                            ADDataAccess.GetDomainDNFromDN(childGroup.DN),
                            childGroupDNSearchCriteria.ChildGroupDNs,
                            childGroupDNSearchCriteria.CurrentRecursionDepth,
                            childGroupDNSearchCriteria.Tasks,
                            childGroupDNSearchCriteria.CTS
                        );

                        childGroupDNSearchCriteria.Tasks.Add(Task.Factory.StartNew(async (x) => {
                            await GetChildGroupDNsAsync(newChildGroupDNSearchCriteria);
                        }, newChildGroupDNSearchCriteria.GroupDN, newChildGroupDNSearchCriteria.CTS.Token, TaskCreationOptions.AttachedToParent, TaskScheduler.Current));

                    }  
                    #endregion
                } 
            });
        }

        public ConcurrentDictionary<string, int> GetChildGroupDNs(string groupDN) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), groupDN ?? "NULL"));

            ConcurrentDictionary<string, int> childGroupDNs = new ConcurrentDictionary<string, int>(StringComparer.OrdinalIgnoreCase);

            var childGroupDNSearchCriteria = new ChildGroupDNSearchCriteria( 
                groupDN:groupDN,
                    domainDN: ADDataAccess.GetDomainDNFromDN(groupDN),
                    childGroupDNs: childGroupDNs, 
                    groupSearchRecursionDepth: 0, 
                    tasks: null, 
                    cts: null
                );

            GetChildGroupDNs(childGroupDNSearchCriteria);

            return childGroupDNSearchCriteria.ChildGroupDNs;
        }
        public void GetChildGroupDNs(ChildGroupDNSearchCriteria childGroupDNSearchCriteria) {

            #region Validation
            if (childGroupDNSearchCriteria == null) {
                throw new ArgumentNullException("childGroupDNSearchCriteria");
            }
            if (string.IsNullOrWhiteSpace(childGroupDNSearchCriteria.GroupDN)) {
                throw new ArgumentNullException("childGroupDNSearchCriteria.GroupDN");
            }
            #endregion

            #region Logging information
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3} domain DN: {4} recursion depth: {5} process thread count: {6}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                childGroupDNSearchCriteria.GroupDN ?? "NULL", childGroupDNSearchCriteria.DomainDN ?? "NULL (All domains)",
                childGroupDNSearchCriteria.CurrentRecursionDepth.ToString(), Process.GetCurrentProcess().Threads.Count));
            #endregion

            #region Leave the domainDN null to search all domains
            if (string.IsNullOrWhiteSpace(childGroupDNSearchCriteria.DomainDN)) {
                foreach (string domainDN in ADDataAccess.ADDomainInfo.Select(x => x.DistinguishedName)) {
                    ChildGroupDNSearchCriteria newChildGroupDNSearchCriteria = new ChildGroupDNSearchCriteria(
                        childGroupDNSearchCriteria.GroupDN,
                        domainDN,
                        childGroupDNSearchCriteria.ChildGroupDNs,
                        childGroupDNSearchCriteria.CurrentRecursionDepth,
                        childGroupDNSearchCriteria.Tasks,
                        childGroupDNSearchCriteria.CTS
                    );

                    GetChildGroupDNs(newChildGroupDNSearchCriteria);
                }
                return;
            }
            #endregion

            #region Check for MaxRecursionDepth
            if (childGroupDNSearchCriteria.CurrentRecursionDepth > ADDataAccess.MaxGroupSearchRecursionDepth) {
                string message = string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3} Canceling search due to GroupSearchRecursionDepth: {4} exceeds MaxGroupSearchRecursionDepth: {5}",
                                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                                childGroupDNSearchCriteria.GroupDN, childGroupDNSearchCriteria.CurrentRecursionDepth, ADDataAccess.MaxGroupSearchRecursionDepth);

                throw new ApplicationException(message);
            }
            #endregion

            string[] propertiesToGet = new string[] { "distinguishedName", "groupType" };

            string filter = string.Format("(&(objectCategory=Group)(distinguishedName={0}))",
                ADDataAccess.ADSearchFilterEncode(childGroupDNSearchCriteria.GroupDN));

            List<GroupProperties> adGroups = ADDataAccess.GetADObjectProperties<GroupProperties>(
                childGroupDNSearchCriteria.DomainDN, propertiesToGet, filter, PartitionScope.LocalPartition);

            if ((adGroups == null) || (adGroups.Count != 1)) {
                throw new ApplicationException(string.Format("Unable to locate one group for DN: {0}.  Group count returned: {1}",
                    childGroupDNSearchCriteria.GroupDN, adGroups != null ? adGroups.Count.ToString() : "NULL"));
            }

            int memberType = 0;

            GroupProperties adGroup = adGroups.First();

            if (adGroup.GroupType.HasFlag(GroupTypeFlags.Local) || adGroup.GroupType.HasFlag(GroupTypeFlags.BuiltIn)) {

                #region Case: Local or Builtin group
                if (adGroup.GetGroupMemberDNs()) {
                    foreach (string memberDN in adGroup.Member.Keys) {
                        // check if this is a group
                        string childFilter = string.Format("(&(objectCategory=Group)(distinguishedName={0}))", memberDN);
                        var memberDomainDN = ADDataAccess.GetDomainDNFromDN(memberDN);

                        List<GroupProperties> childGroups = ADDataAccess.GetADObjectProperties<GroupProperties>(
                            memberDomainDN, propertiesToGet, childFilter, PartitionScope.LocalPartition);

                        if ((childGroups == null) || (childGroups.Count != 1)) continue;
                        GroupProperties childGroup = childGroups.Single();

                        if (!childGroupDNSearchCriteria.ChildGroupDNs.ContainsKey(memberDN)) {
                            childGroupDNSearchCriteria.ChildGroupDNs.AddOrUpdate(memberDN, 0, (TKey, TOldValue) => memberType);
                        }
                        else {
                            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} ChildGroupDNs already contains DN: {3}",
                                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), memberDN));
                            continue;
                        }

                        // requeue this dn to check if it is a group and what the group members are
                        ChildGroupDNSearchCriteria newChildGroupDNSearchCriteria = new ChildGroupDNSearchCriteria(
                            groupDN: memberDN,
                            domainDN: ADDataAccess.GetDomainDNFromDN(memberDN),
                            childGroupDNs: childGroupDNSearchCriteria.ChildGroupDNs,
                            groupSearchRecursionDepth: childGroupDNSearchCriteria.CurrentRecursionDepth,
                            tasks: childGroupDNSearchCriteria.Tasks,
                            cts: childGroupDNSearchCriteria.CTS
                        );

                        GetChildGroupDNs(newChildGroupDNSearchCriteria);

                    } // foreach (string memberDN in adGroup.Member.Keys)
                } // if (adGroup.GetGroupMemberDNs()) 
                #endregion

            } 
            else {

                #region Normal case test
                string memberOfFilter = string.Format("(&(objectCategory=group)(memberOf={0}))", adGroup.DN);

                List<GroupProperties> memberGroups = ADDataAccess.GetADObjectProperties<GroupProperties>(
                    ADDataAccess.ForestRootDN, propertiesToGet, memberOfFilter, PartitionScope.GlobalCatalog);

                if ((memberGroups == null) || (memberGroups.Count == 0)) return;

                // Find the DNs for groups that are a member of this group
                foreach (var childGroup in memberGroups) {
                    if (!childGroupDNSearchCriteria.ChildGroupDNs.ContainsKey(childGroup.DN)) {
                        childGroupDNSearchCriteria.ChildGroupDNs.AddOrUpdate(childGroup.DN, 0, (TKey, TOldValue) => memberType);
                    }
                    else {
                        Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} ChildGroupDNs already contains DN: {3}",
                            DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), childGroup.DN));
                        continue;
                    }

                    // recursively build a list of all the groups that are a member of this group
                    ChildGroupDNSearchCriteria newChildGroupDNSearchCriteria = new ChildGroupDNSearchCriteria(
                        groupDN: childGroup.DN,
                        domainDN: ADDataAccess.GetDomainDNFromDN(childGroup.DN),
                        childGroupDNs: childGroupDNSearchCriteria.ChildGroupDNs,
                        groupSearchRecursionDepth: childGroupDNSearchCriteria.CurrentRecursionDepth,
                        tasks: childGroupDNSearchCriteria.Tasks,
                        cts: childGroupDNSearchCriteria.CTS
                    );

                    GetChildGroupDNs(newChildGroupDNSearchCriteria);

                } // foreach (ADGroupProperties adChildGroup in adGroups) 
                #endregion

            } 
        }

        /// <summary>
        /// Provides list of AD group memberships for which the user is a direct member.
        /// Does NOT include nested or indirect groups.
        /// Does include direct UGRP membership in other domains. 
        /// </summary>
        public IReadOnlyList<GroupProperties> GetDirectGroupMemberships(string objectDN) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}] {2} dn: {3}", DateTime.Now.TimeOfDay,
                Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), objectDN ?? "NULL"));

            var directGroups = new List<GroupProperties>();
            var groupDNs = GetMemberOf(objectDN);

            int startIndex = 0;
            do {
                List<string> searchList = groupDNs.Skip(startIndex).Take(ADDataAccess.MaxValRange).ToList();
                if (searchList.Count == 0) continue;

                var queryFilter = new StringBuilder();
                // build a query filter (list of DN's OR'd together)
                queryFilter.Append("(|");
                foreach (string dn in groupDNs) {
                    queryFilter.AppendFormat("(distinguishedName={0})", ADDataAccess.ADSearchFilterEncode(dn));
                }
                queryFilter.Append(")");

                foreach (var groupProperties in ADDataAccess.GetADObjectProperties<GroupProperties>(
                    ADDataAccess.ForestRootDN, null, queryFilter.ToString(), PartitionScope.GlobalCatalog)) {
                    directGroups.Add(groupProperties);
                }

                startIndex += searchList.Count;
            } while (startIndex < groupDNs.Count);

            return directGroups as IReadOnlyList<GroupProperties>;
        }

        public async Task<Dictionary<string, T>> GetGroupMemberDetailsAsync<T>(string groupDN) where T : ADBaseObjectProperties, new() {
            return await GetGroupMemberDetailsAsync<T>(groupDN, null);
        }
        public async Task<Dictionary<string, T>> GetGroupMemberDetailsAsync<T>(string groupDN, string[] propertiesToGet) where T : ADBaseObjectProperties, new() {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), groupDN ?? "NULL"));

            #region Validation
            if (string.IsNullOrWhiteSpace(groupDN)) {
                throw new ArgumentNullException("groupDN");
            }
            #endregion

            return await Task.Run<Dictionary<string, T>>(() => {

                var stopwatch = Stopwatch.StartNew();
                var groupMemberDetails = new Dictionary<string, T>(StringComparer.OrdinalIgnoreCase);

                var groupMembers = new Dictionary<string, T>(StringComparer.OrdinalIgnoreCase);
                var groupMemberDNs = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
                GetGroupMemberDNs(groupDN, groupMemberDNs, recursive: true);

                try {
                    var tasks = new List<Task>();
                    foreach (string groupMemberDN in groupMemberDNs.Keys) {
                        tasks.Add(GetGroupMemberDetails<T>(groupMemberDN, groupMemberDetails));
                    }

                    Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3}; waiting for {4} tasks to complete",
                        DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                        groupDN, tasks.Count));

                    Task.WaitAll(tasks.ToArray());

                    foreach (var task in tasks) {
                        if (task.IsFaulted && task.Exception != null) {
                            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3}; Exception: {4}",
                                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                                task.Exception.VerboseExceptionString()));
                        }
                    }

                }
                catch (Exception e) {
                    Debug.WriteLine(string.Format(@"{0}  [ThreadID: {1}]  {2} Exception: {3}",
                        DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), e.VerboseExceptionString()));

                    throw;
                }
                finally {
                    Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} finished getting member details for group DN: {3}. Member count: {4} Time required: {5}",
                        DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                        groupDN, groupMemberDetails.Count, stopwatch.Elapsed));
                }

                return groupMemberDetails;

            });
        }
        private async Task GetGroupMemberDetails<T>(string groupMemberDN, Dictionary<string, T> groupMemberDetails)
            where T : ADBaseObjectProperties, new() {
            await GetGroupMemberDetails<T>(groupMemberDN, groupMemberDetails, null);
        }
        private async Task GetGroupMemberDetails<T>(string groupMemberDN, Dictionary<string, T> groupMemberDetails, string[] propertiesToGet)
            where T : ADBaseObjectProperties, new() {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} groupMember DN: {3}; groupMemberDetails count: {4}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                groupMemberDN ?? "NULL", groupMemberDetails != null ? groupMemberDetails.Count.ToString() : "NULL"));

            #region Validation
            if (string.IsNullOrWhiteSpace(groupMemberDN)) {
                throw new ArgumentNullException("groupMemberDN");
            }
            if (groupMemberDetails == null) groupMemberDetails = new Dictionary<string, T>();
            #endregion

            await Task.Run(() => {
                lock (groupMemberDetails) {
                    if (groupMemberDetails.ContainsKey(groupMemberDN)) return;
                }

                var domainDN = ADDataAccess.GetDomainDNFromDN(groupMemberDN);
                List<T> groupMembers = ADDataAccess.GetADObjectProperties<T>(
                    domainDN, propertiesToGet, "distinguishedName=" + groupMemberDN, PartitionScope.LocalPartition);

                if ((groupMembers != null) && (groupMembers.Count == 1)) {
                    T adUser = groupMembers.First();
                    lock (groupMemberDetails) {
                        if (!groupMemberDetails.ContainsKey(groupMemberDN)) groupMemberDetails.Add(groupMemberDN, adUser);
                    }
                }
                else {
                    Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} Warning: results not found for groupMemberDN: {3}",
                        DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                        groupMemberDN));
                }
            });
        }

        /// <summary>
        /// Key: Group the member DN's of the specified group
        /// </summary>
        /// <param name="groupLdapDN"></param>
        /// <returns>Dictionary of group member DNs Key: DN  Value: (int)GroupMemberType
        /// </returns>
        /// <remarks>
        /// Not fully tested for external code that may be calling this method.
        /// Uses paging to avoid server limits.  
        /// Binds to the group object and extracts the contents of the member attribute.
        /// </remarks>
        public async Task<Dictionary<string, int>> GetGroupMemberDNsAsync(string groupDN, Dictionary<string, int> groupMemberDNs = null, bool recursive = false) {

            return await Task.Run<Dictionary<string, int>>(() => {
                this.GetGroupMemberDNs(groupDN, groupMemberDNs, recursive);
                return groupMemberDNs;
            });
        }

        /// <summary>
        /// Key: Group the member DN's of the specified group
        /// </summary>
        /// <param name="groupLdapDN"></param>
        /// <returns>Dictionary of group member DNs Key: DN  Value: (int)GroupMemberType
        /// </returns>
        public Dictionary<string, int> GetGroupMemberDNs(string groupDN, Dictionary<string, int> groupMemberDNs = null, bool recursive = false) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3} recursive: {4}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                groupDN ?? "NULL", recursive));

            var stopwatch = Stopwatch.StartNew();

            #region Validation
            if (string.IsNullOrWhiteSpace(groupDN)) {
                throw new ArgumentNullException("groupDN");
            }
            if (groupMemberDNs == null) groupMemberDNs = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
            #endregion

            var groupDNs = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
            groupDNs.Add(groupDN, (int)GroupMemberType.Group);

            #region Get child group DN's
            if (recursive) {
                ConcurrentDictionary<string, int> childGroupDNs = GetChildGroupDNs(groupDN);

                if (childGroupDNs != null) {
                    foreach (string childGroupDN in childGroupDNs.Keys) {
                        if (!groupDNs.ContainsKey(childGroupDN)) {
                            groupDNs.Add(childGroupDN, (int)GroupMemberType.Group);
                        }
                    }
                }
                else {
                    Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} null results returned for DN: {3}", 
                        DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), groupDN ?? "NULL"));
                }
            }
            #endregion

            // add child groups to the list of member dn's.  Identify them as groups by the dictionary member value
            foreach (string dn in groupDNs.Keys) {
                lock (groupMemberDNs) {
                    if (!groupMemberDNs.ContainsKey(dn) && !string.Equals(dn, groupDN, StringComparison.OrdinalIgnoreCase)) {
                        groupMemberDNs.Add(dn, (int)GroupMemberType.Group);
                    }
                }
            }

            foreach (var item in groupDNs) {
                var methodParameters = new MethodParameters() {
                    BaseDN = item.Key,
                    ObjectDN = ADDataAccess.GetDNFromLdapDN(item.Key),
                    PartitionScope = ActiveDirectory.PartitionScope.LocalPartition,
                    PropertiesToGet = new string[] { "member" },
                    ServerNameOrAdjustedBindPath = ADDataAccess.GetAdjustedBindPath(ADDataAccess.GetDNFromLdapDN(item.Key), PartitionScope.LocalPartition)
                };

                Dictionary<string, int> newGroupMemberDNs =
                    ConnectionManager.DirectoryServerHandler<MethodParameters, Dictionary<string, int>>(
                        ref methodParameters,
                        (x) => {

                            #region Validation
                            if (!ADDataAccess.ObjectExists(methodParameters.ServerNameOrAdjustedBindPath)) {
                                throw new ApplicationException(string.Format("Path does not exist: {0}", methodParameters.ServerNameOrAdjustedBindPath));
                            }
                            #endregion

                            SearchResult result = ADDataAccess.GetSearchResult(methodParameters);
                            if (result != null) {
                                using (var groupDirectoryEntry = result.GetDirectoryEntry()) {
                                    return GetGroupMemberDNs(groupDirectoryEntry);
                                }
                            }
                            else {
                                return null;
                            }
                        });

                if (newGroupMemberDNs != null) {
                    // distinguish between direct and nested group members
                    int groupMemberType = string.Equals(item.Key, groupDN, StringComparison.OrdinalIgnoreCase) 
                        ? (int)GroupMemberType.Direct 
                        : (int)GroupMemberType.Nested;

                    foreach (var kvp in newGroupMemberDNs) {
                        lock (groupMemberDNs) {
                            if (!groupMemberDNs.ContainsKey(kvp.Key)) {
                                groupMemberDNs.Add(kvp.Key, groupMemberType);
                            }
                        }
                    }
                }
            } // foreach (var item in groupDNs)

            if (groupMemberDNs != null) {
                lock (groupMemberDNs) {
                    // when returning the groupDNs that are in the member list, do not include the source DN,
                    // if for some odd reason that the group is a member of itself
                    if (groupMemberDNs.ContainsKey(groupDN)) {
                        groupMemberDNs.Remove(groupDN);
                    }
                }
            }

            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} finished getting group member DN's for DN: {3}. Group members found: {4} Time required: {5}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                groupDN ?? "NULL", groupMemberDNs.Count, stopwatch.Elapsed));

            return groupMemberDNs;
        }
        public Dictionary<string, int> GetGroupMemberDNs(DirectoryEntry groupDirectoryEntry) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2}", DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName()));
            if (groupDirectoryEntry == null) return null;

            var groupMemberDNs = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
            // it's possible that the member attribute may not be present
            if (!groupDirectoryEntry.Properties.Contains("member")) return groupMemberDNs;

            // get group members using paging, to ensure that we can get all the members
            // from groups that exceed the server limit for ojects (usually 1500)
            int rangeStart = 0;
            int rangeEnd = ADDataAccess.MaxValRange - 1;
            int rangeOffset = (int)((double)ADDataAccess.MaxValRange * 0.1);
            string[] memberRange = { "" };

            do {
                memberRange[0] = string.Format("member;Range={0}-{1}", rangeStart, rangeEnd);
                groupDirectoryEntry.RefreshCache(memberRange);

                // add group members
                for (int index = 0; index < groupDirectoryEntry.Properties["member"].Count; index++) {
                    string memberDN = groupDirectoryEntry.Properties["member"][index].ToString();
                    if (ADDataAccess.ExcludedDomains.Any(excludedDomain => Regex.IsMatch(memberDN, string.Format("DC={0}", excludedDomain), RegexOptions.IgnoreCase))) continue;
                    if (!groupMemberDNs.ContainsKey(memberDN)) groupMemberDNs.Add(memberDN, 0);
                }

                // use a rangeOffset here to compensate for anomalies with the count that AD reports and the actual
                // count of members in the group
                rangeStart += (ADDataAccess.MaxValRange - rangeOffset);
                rangeEnd += (ADDataAccess.MaxValRange - rangeOffset);

                // member count is the count at the beginning of the loop, before the cache is refreshed again  
            } while (groupDirectoryEntry.Properties["member"].Count >= ADDataAccess.MaxValRange);

            return groupMemberDNs;
        }

        /// <summary>
        /// Returns a GroupPrincipal object for the specified group SamAccountName in the specified domain
        /// </summary>
        /// <param name="domainDN">DN of domain</param>
        /// <param name="groupSamAccountName">SamAccountName of group</param>
        /// <param name="principalContext">Can be null or a valid PrincipalContext</param>
        /// <remarks>You must dispose of both the PrincipalContext and GroupPrincipal objects</remarks>
        /// <returns></returns>
        public IEnumerable<GroupPrincipal> GetGroupPrincipal(string groupSamAccountName) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group: {3}", 
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                groupSamAccountName ?? "NULL"));
            if (string.IsNullOrWhiteSpace(groupSamAccountName)) yield break;

            foreach (string domainDN in ADDataAccess.ADDomainInfo.Select(x => x.DistinguishedName)) {
                yield return GetGroupPrincipal(domainDN, groupSamAccountName);
            }
        }
        public GroupPrincipal GetGroupPrincipal(string domain, string groupSamAccountName) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} domain: {3} group: {4}", 
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), 
                domain ?? "NULL", groupSamAccountName ?? "NULL"));

            #region Validation
            if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrWhiteSpace(groupSamAccountName)) {
                throw new ApplicationException(string.Format("Parameter(s) not specified:  domain: {0} groupSamAccountName: {1}",
                    domain ?? "NULL", groupSamAccountName ?? "NULL"));
            }

            DomainConfiguration domainInfo = ADDataAccess.ADDomainInfo.Where(x => string.Equals(x.DistinguishedName, domain, StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
            if (domainInfo == null) domainInfo = ADDataAccess.ADDomainInfo.Where(x => string.Equals(x.NetbiosName, domain, StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
            if (domainInfo == null) {
                throw new ApplicationException(string.Format("Domain not found: {0}", domain));
            }
            #endregion

            var methodParameters = new MethodParameters() {
                DomainInfo = domainInfo,
                SamAccountName = groupSamAccountName.Trim()
            };

            return ConnectionManager.DirectoryServerHandler<MethodParameters, GroupPrincipal>(
                    ref methodParameters,
                    (x) => {
                        var availableDC = ConnectionManager.SelectDC(methodParameters.DomainInfo.NetbiosName);
                        methodParameters.ServerNameOrAdjustedBindPath = availableDC.DNSName;

                        GroupPrincipal groupPrincipal = null;

                        var principalContext = new PrincipalContext(ContextType.Domain, availableDC.DNSName, methodParameters.DomainInfo.DistinguishedName);
                        groupPrincipal = GroupPrincipal.FindByIdentity(principalContext, IdentityType.SamAccountName, methodParameters.SamAccountName);

                        return groupPrincipal;
                    });
        }
        /// <summary>
        /// Get group members domain\samaccountname
        /// </summary>
        /// <remarks>Performance for large groups is poor</remarks>
        public IReadOnlyList<string> GetGroupPrincipals(string domain, string rolename) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2}", 
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName()));

            #region Validation
            if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrWhiteSpace(rolename)) {
                throw new ApplicationException(string.Format("Parameter(s) not specified:  domain: {0} rolename: {1}",
                    domain ?? "NULL", rolename ?? "NULL"));
            }

            DomainConfiguration domainInfo = ADDataAccess.ADDomainInfo.Where(x => string.Equals(x.DistinguishedName, domain, StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
            if (domainInfo == null) domainInfo = ADDataAccess.ADDomainInfo.Where(x => string.Equals(x.NetbiosName, domain, StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
            if (domainInfo == null) {
                throw new ApplicationException(string.Format("Domain not found: {0}", domain));
            }
            #endregion

            var methodParameters = new MethodParameters() {
                DomainInfo = domainInfo,
                SamAccountName = rolename.Trim()
            };

            return ConnectionManager.DirectoryServerHandler<MethodParameters, IReadOnlyList<string>>(
                    ref methodParameters,
                    (x) => {
                        var availableDC = ConnectionManager.SelectDC(methodParameters.DomainInfo.NetbiosName);
                        methodParameters.ServerNameOrAdjustedBindPath = availableDC.DNSName;

                        var groupPrincipalNames = new List<string>();

                        using (var principalContext = new PrincipalContext(ContextType.Domain, availableDC.DNSName))
                        using (var groupPrincipal = GroupPrincipal.FindByIdentity(principalContext, IdentityType.SamAccountName, ADDataAccess.ADSearchFilterEncode(methodParameters.SamAccountName)))
                        using (PrincipalSearchResult<Principal> principals = groupPrincipal.GetMembers(recursive: true)) {
                            foreach (Principal groupMemberPrincipal in principals) {
                                methodParameters.ServerNameOrAdjustedBindPath = groupMemberPrincipal.Context.ConnectedServer;
                                UserPrincipal userPrincipal = groupMemberPrincipal as UserPrincipal;
                                if (userPrincipal != null) {
                                    Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} User: {3}",
                                        DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId,
                                        CustomExtensions.CurrentMethodName(), userPrincipal.DistinguishedName));

                                    var userPrincipalDomainDN = ADDataAccess.GetDomainDNFromDN(userPrincipal.DistinguishedName);

                                    if (!string.IsNullOrWhiteSpace(userPrincipalDomainDN)) {
                                        var userPrincipalDomainInfo = ADDataAccess.ADDomainInfo
                                            .Where(y => string.Equals(y.DistinguishedName, userPrincipalDomainDN, StringComparison.OrdinalIgnoreCase))
                                            .FirstOrDefault();

                                        if (userPrincipalDomainInfo != null) {
                                            groupPrincipalNames.Add(string.Format(@"{0}\{1}", userPrincipalDomainInfo.NetbiosName, userPrincipal.SamAccountName));
                                        }
                                    }
                                }
                            }
                        }

                        return groupPrincipalNames as IReadOnlyList<string>;
                    });
        }

        /// <summary>
        /// The input is expected to be an AD user object, via a SearchResultCollection.
        /// Iterate over the "tokenGroups" collection to extract the list of group memberships.
        /// </summary>
        /// <param name="dn"> distinguished name of the security principal</param>
        /// <param name="groupQueryResults">The search query object to store results</param>
        /// <returns>IdentityReferenceCollection (collection of SecurityIdentifier)</returns>
        /// <remarks>Translating the IdentityReference to SecurityIdentifier is safer than an NTAccount object, due to if the account no longer exists,
        /// translating to an NTAccount principal will throw an exception.
        /// </remarks>
        public IdentityReferenceCollection GetTokenGroups(string dn) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2}", DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName()));

            #region Validation
            if (string.IsNullOrWhiteSpace(dn)) {
                throw new ArgumentNullException("dn");
            }
            #endregion

            MethodParameters methodParameters = new MethodParameters() {
                BaseDN = dn,
                ObjectDN = dn,
                PartitionScope = PartitionScope.LocalPartition,
                PropertiesToGet = new string[] { "distinguishedName" }
            };

            return ConnectionManager.DirectoryServerHandler<MethodParameters, IdentityReferenceCollection>(
                    ref methodParameters,
                    (x) => {
                        if (!ADDataAccess.ObjectExists(methodParameters.ObjectDN)) return null;

                        SearchResult result = ADDataAccess.GetSearchResult(methodParameters);
                        methodParameters.ServerNameOrAdjustedBindPath = result.Path;

                        var irc = new IdentityReferenceCollection();
                        using (var directoryEntry = result.GetDirectoryEntry()) {
                            directoryEntry.RefreshCache(new string[] { "tokenGroups" });
                            foreach (byte[] sidBytes in directoryEntry.Properties["tokenGroups"]) {
                                irc.Add(new SecurityIdentifier(sidBytes, 0));
                            }
                        }

                        return irc;
                    });
        }
        public SecurityIdentifier[] GetTokenGroupsSIDs(string domain, string securityPrincipal) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} domain: {3} securityPrincipal: {4}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId,
                CustomExtensions.CurrentMethodName(), domain ?? "NULL", securityPrincipal ?? "NULL"));

            #region Validation
            if (string.IsNullOrWhiteSpace(domain) || string.IsNullOrWhiteSpace(securityPrincipal)) {
                throw new ApplicationException(string.Format("Parameter(s) not specified:  domain: {0} securityPrincipal: {1}",
                    domain ?? "NULL", securityPrincipal ?? "NULL"));
            }

            DomainConfiguration domainInfo = ADDataAccess.ADDomainInfo.Where(x => string.Equals(x.DistinguishedName, domain, StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
            if (domainInfo == null) domainInfo = ADDataAccess.ADDomainInfo.Where(x => string.Equals(x.NetbiosName, domain, StringComparison.OrdinalIgnoreCase)).FirstOrDefault();
            if (domainInfo == null) {
                throw new ApplicationException(string.Format("Domain not found: {0}", domain));
            }
            #endregion

            var filter = string.Format("(samAccountName={0})", securityPrincipal);
            var propertiesToGet = new string[] { "distinguishedName" };

            var userGroupComputer = ADDataAccess.GetADObjectProperties<UserGroupComputer>(
                domainInfo.DistinguishedName, propertiesToGet, filter, PartitionScope.LocalPartition);

            if ((userGroupComputer == null) || (userGroupComputer.Count != 1)) return new SecurityIdentifier[] { };
            return GetTokenGroupsSIDs(userGroupComputer.First().DN);
        }
        public SecurityIdentifier[] GetTokenGroupsSIDs(string dn) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} DN: {3}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId,
                CustomExtensions.CurrentMethodName(), dn ?? "NULL"));

            IdentityReferenceCollection irc = GetTokenGroups(dn);
            SecurityIdentifier[] sids = irc.Select(x => x.Translate(typeof(SecurityIdentifier)) as SecurityIdentifier).ToArray();
            return sids;
        }

        /// <summary>
        /// Invalid characters: \/[]:;|=,+*?<>"
        /// 64 character maximum
        /// Cannot start or end with space or . (dot, period)
        /// </summary>
        public bool IsGroupSamAccountNameValid(string samAccountName) {
            bool isValid = (samAccountName.Length > 0) 
                && (samAccountName.Length <= 64) 
                && !samAccountName.StartsWith(".") 
                && !samAccountName.EndsWith(".")
                && !samAccountName.StartsWith(" ") 
                && !samAccountName.EndsWith(" ");

            if (isValid) {
                isValid = Regex.IsMatch(samAccountName, ADDataAccess.SamAccountNameDoesNotContainInvalidCharactersFilter);
            }

            return isValid;
        }

        #region Add/Remove Group members methods
        /// <summary>
        /// Adds the specified member DN's to a group
        /// </summary>
        /// <param name="groupDN"></param>
        /// <param name="memberDN"></param>
        /// <returns></returns>
        public bool AddMemberToGroup(string groupDN, string memberDN) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3} member DN: {4}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                groupDN ?? "NULL", memberDN ?? "NULL"));

            var memberDNs = new List<string>();
            memberDNs.Add(memberDN);
            return AddMembersToGroup(groupDN, memberDNs);
        }
        public bool AddMembersToGroup(string groupDN, List<string> memberDNs) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} for group DN: {3}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), 
                groupDN ?? "NULL"));

            #region Validation
            if (string.IsNullOrWhiteSpace(groupDN) || (memberDNs == null) || (memberDNs.Count == 0)) {
                throw new ApplicationException(string.Format("Parameter(s) not specified: groupDN: {0} memberDNs count: {1}",
                    groupDN ?? "NULL", memberDNs != null ? memberDNs.Count.ToString() : "NULL"));
            }
            #endregion

            var methodParameters = new MethodParameters() {
                ObjectDN = groupDN.Trim(),
                GroupMemberDNsToAdd = memberDNs,
                ExistingGroupMemberDNs = GetGroupMemberDNs(groupDN)
            };

            return ConnectionManager.DirectoryServerHandler<MethodParameters, bool>(
                    ref methodParameters,
                    (x) => {
                        methodParameters.ServerNameOrAdjustedBindPath = ADDataAccess.GetAdjustedBindPath(forupdate: true, bindPath: methodParameters.ObjectDN);
                        if (!ADDataAccess.ObjectExists(methodParameters.ServerNameOrAdjustedBindPath)) return false;

                        using (var directoryEntry = new DirectoryEntry(methodParameters.ServerNameOrAdjustedBindPath, null, null, methodParameters.AuthenticationTypes)) {
                            
                            return AddMembersToGroup(directoryEntry, methodParameters.GroupMemberDNsToAdd, methodParameters.ExistingGroupMemberDNs);
                        }
                    });
        }
        private bool AddMembersToGroup(DirectoryEntry groupDirectoryEntry, List<string> newMemberDNs, Dictionary<string, int> existingMemberDNs) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2}", DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName()));
            if ((groupDirectoryEntry == null) || (newMemberDNs == null) || (existingMemberDNs == null)) {
                throw new ApplicationException(string.Format("Parameter(s) not specified: groupDirectoryEntry: {0} newMemberDNs count: {1} existingMemberDNs count: {2}",
                    groupDirectoryEntry != null ? groupDirectoryEntry.Path : "NULL", newMemberDNs != null ? newMemberDNs.Count.ToString() : "NULL",
                    existingMemberDNs != null ? existingMemberDNs.Count.ToString() : "NULL"));
            }

            int currentNewMember = 0;

            do {
                // add new member DNs if it does not exist (compare to temporary dictionary)
                for (int index = 0; (index < ADDataAccess.MaxValRange) && (currentNewMember < newMemberDNs.Count); index++, currentNewMember++) {
                    // remove any occurrences of LDAP:// or GC:// prefixes from the member DN
                    string newMemberDN = ADDataAccess.GetDNFromLdapDN(newMemberDNs[currentNewMember]);
                    if (!existingMemberDNs.ContainsKey(newMemberDN)) {
                        groupDirectoryEntry.Properties["member"].Add(newMemberDN);
                    }
                }

                groupDirectoryEntry.CommitChanges();
                groupDirectoryEntry.RefreshCache();

            } while (currentNewMember < newMemberDNs.Count);

            return true;
        }

        private bool RemoveAllMembersFromGroup(string groupDN) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} for DN: {3}", 
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), 
                groupDN ?? "NULL"));

            #region Validation
            if (string.IsNullOrWhiteSpace(groupDN)) {
                throw new ArgumentNullException("groupDN");
            }
            #endregion

            var methodParameters = new MethodParameters() {
                ObjectDN = groupDN.Trim()
            };

            return ConnectionManager.DirectoryServerHandler<MethodParameters, bool>(
                    ref methodParameters,
                    (x) => {
                        methodParameters.ServerNameOrAdjustedBindPath = ADDataAccess.GetAdjustedBindPath(forupdate: true, bindPath: methodParameters.ObjectDN);
                        if (!ADDataAccess.ObjectExists(methodParameters.ServerNameOrAdjustedBindPath)) return false;

                        using (var groupDirectoryEntry = new DirectoryEntry(methodParameters.ServerNameOrAdjustedBindPath, null, null, methodParameters.AuthenticationTypes)) {
                            
                            if (groupDirectoryEntry.Properties.Contains("member")) {
                                groupDirectoryEntry.Properties["member"].Value = new string[0];
                                groupDirectoryEntry.CommitChanges();
                                groupDirectoryEntry.RefreshCache();
                            }

                            return true;
                        }
                    });
        }
        public bool RemoveMemberFromGroup(string groupDN, string memberDN) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} group DN: {3} member DN: {4}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(),
                groupDN ?? "NULL", memberDN ?? "NULL"));

            var memberDNs = new List<string>();
            memberDNs.Add(memberDN);
            return RemoveMembersFromGroup(groupDN, memberDNs);
        }
        /// <summary>
        /// Remove members from a group.  If list is null, all members are removed.
        /// </summary>
        /// <param name="groupDN"></param>
        /// <param name="memberDNsToRemove"></param>
        /// <returns></returns>
        public bool RemoveMembersFromGroup(string groupDN, List<string> memberDNsToRemove) {
            Debug.WriteLine(string.Format("{0}  [ThreadID: {1}]  {2} groupDN: {3}",
                DateTime.Now.TimeOfDay, Thread.CurrentThread.ManagedThreadId, CustomExtensions.CurrentMethodName(), 
                groupDN ?? "NULL"));

            #region Validation
            if (string.IsNullOrWhiteSpace(groupDN) || (memberDNsToRemove == null) || (memberDNsToRemove.Count == 0)) {
                throw new ApplicationException(string.Format("Parameter(s) not specified: groupDN: {0} memberDNs count: {1}",
                groupDN ?? "NULL", memberDNsToRemove != null ? memberDNsToRemove.Count.ToString() : "NULL"));
            }
            #endregion

            var methodParameters = new MethodParameters() {
                ObjectDN = groupDN.Trim(),
                GroupMemberDNsToRemove = memberDNsToRemove,
                ExistingGroupMemberDNs = GetGroupMemberDNs(groupDN)
            };

            return ConnectionManager.DirectoryServerHandler<MethodParameters, bool>(
                    ref methodParameters,
                    (x) => {
                        methodParameters.ServerNameOrAdjustedBindPath = ADDataAccess.GetAdjustedBindPath(forupdate: true, bindPath: methodParameters.ObjectDN);
                        if (!ADDataAccess.ObjectExists(methodParameters.ServerNameOrAdjustedBindPath)) return false;

                        using (var directoryEntry = new DirectoryEntry(methodParameters.ServerNameOrAdjustedBindPath, null, null, methodParameters.AuthenticationTypes)) {
                            
                            // add new member DNs if it does not exist (compare to temporary dictionary)
                            foreach (string memberLdapDN in methodParameters.GroupMemberDNsToRemove) {
                                // remove any occurrences of LDAP:// or GC:// prefixes from the member DN
                                string memberDN = ADDataAccess.GetDNFromLdapDN(memberLdapDN);
                                if (methodParameters.ExistingGroupMemberDNs.ContainsKey(memberDN)) {
                                    directoryEntry.Properties["member"].Remove(memberDN);
                                }
                            }

                            directoryEntry.CommitChanges();
                            directoryEntry.RefreshCache();

                            return true;
                        }
                    });
        }
        #endregion
    }
}
